fix(plugin-auth): collapse the three basePath derivations onto one normalisation chain - #17002
Conversation
`AuthManager` derived its base path in three independent places —
`getBasePath()`, `getAuthIssuer()` and `getMcpResourceUrl()` — each reading
`this.config.basePath` and normalising it its own way. One of them built a
value that is not a URL.
`getMcpResourceUrl()` added no leading slash, so a `basePath` configured
without one produced `http://localhost:3000api/v1/mcp`. Measured: `new URL()`
throws on it (`3000api` is not a port), so `auth-plugin.ts`'s
`new URL(manager.getMcpResourceUrl()).pathname` — which mounts the RFC 9728
§3.1 path-inserted well-known route — throws too, and
`@better-auth/oauth-provider` 1.7.2 refuses it at plugin init:
oauth-provider: skipping resource seed for http://localhost:3000api/v1/mcp
— resource identifier ... must be an absolute URI (RFC 8707 §2)
Post-#16780 that identifier seeds the `sys_oauth_resource` row (`resources`)
and links every DCR client to it (`clientRegistrationDefaultResources`), with
`enforcePerClientResources` at its `true` default — so the row is never
written and every MCP client is refused. That input class could never mint or
match a token, which is why repairing it re-selects nothing.
There is now exactly ONE read of `this.config.basePath` in the file and one
chain above it:
configuredBasePath() the configured value VERBATIM — what better-auth is handed
└─ rootedBasePath() + a leading slash when absent (better-auth's own rule)
├─ getAuthIssuer() = origin + this
└─ getBasePath() = this, trailing slashes stripped
└─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp`
A fourth normaliser cannot be added without deleting a link of that chain.
⛔ `getAuthIssuer()` is NOT canonicalised, and the card's second defect is not
repaired here because it does not exist on `main`. The card and its triage
both read PR #16380 as having handed better-auth the STRIPPED base path while
`getAuthIssuer()` broadcast the RETAINED one. #16380's last commit ("hand
better-auth the configured basePath verbatim again") reverted exactly that,
having measured that it rejects every token minted under a trailing-slash
`basePath`. Measured here on a real `betterAuth()`, reading
`(await auth.$context).baseURL` — the value the oauth-provider stamps as `iss`
— `getAuthIssuer()` already equals it for all six spellings probed. A new
case pins that equality against the real instance, so the divergence cannot be
introduced by a later "canonicalisation".
`getAuthIssuer()` and `getBasePath()` are byte-identical to before for every
spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical
`basePath`. The canonical-input control asserts all three getters unchanged.
Refs #16399
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…sepath-normaliser-consolidation
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1831c85fe35ef593f524cb7563a6efb1d1a6a7a9 && git checkout 1831c85fe35ef593f524cb7563a6efb1d1a6a7a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db769829f856f1947de53ac6eff0927ded75364f e441ba11aa9b1078a7c3a549fc49e81c005e26ba && git checkout -B drift-repro db769829f856f1947de53ac6eff0927ded75364f && git merge --no-ff e441ba11aa9b1078a7c3a549fc49e81c005e26ba
node scripts/docs-audit/affected-docs.mjs --json db769829f856f1947de53ac6eff0927ded75364f
|
Part of #16399
Clause-②: no
AuthManagerderived its base path in three independent places —getBasePath(),getAuthIssuer()andgetMcpResourceUrl()— each readingthis.config.basePathand normalising it its own way. This lands one chain, so the count cannot grow,
and repairs the one derivation that produced a value that is not a URL.
main. The measurement is below; the half of the card that stays open is named atthe end.
Anchors, re-derived by text (⛔ not inherited)
Triage cited
:5570/:5902/:5912. On the tree this branch was cut from(
7cd587413) all three had drifted 120 lines:getBasePath()getAuthIssuer()getMcpResourceUrl()validAudiencesis gone, as the dispatch said: zero hits repo-wide outside threedeliberate do-not-reintroduce tombstones. The current consumer list for
getMcpResourceUrl(), re-derived by grep:auth-manager.tsresources: [...]sys_oauth_resourcerow at plugin init — persistedauth-manager.tsclientRegistrationDefaultResources: [...]auth-manager.tsgetMcpProtectedResourceMetadata()resourcefieldauth-manager.tsverifyMcpAccessToken()audience:— theauda token is matched againstauth-plugin.tsnew URL(...).pathname, to mount the RFC 9728 §3.1 well-known routeruntime/src/domains/mcp.tsspec/src/contracts/auth-service.tsIAuthServiceDefect 1 — the value was not a URL, and nothing downstream tolerated it
Measured on a real
AuthManagerat7cd587413,baseUrl: 'http://localhost:3000':Three independent confirmations that this is not an alternative spelling:
new URL('http://localhost:3000api/v1/mcp')throwsInvalid URL—3000apiis not a port. So
auth-plugin.ts'snew URL(manager.getMcpResourceUrl()).pathnamethrows too, at discovery-route registration.
@better-auth/oauth-provider1.7.2 refuses it at plugin init, in its own words,observed on a real instance built by
createAuthInstance:oauth-provider: skipping resource seed for http://localhost:3000api/v1/mcp — resource identifier http://localhost:3000api/v1/mcp must be an absolute URI (RFC 8707 §2)enforcePerClientResourcesat itstruedefault,every MCP client is refused for want of a link row.
⇒ under that configuration no token could ever have been minted or matched, so
repairing it re-selects nothing. This is triage's ruling, now with the runtime's own
refusal message behind it.
Defect 2 — ⛔ the premise is falsified; this PR pins that it stays that way
The card and its triage both read PR #16380 as having created a one-sided divergence:
better-auth handed the stripped base path while
getAuthIssuer()broadcast theretained one. That is not what landed.
git log -S 'basePath: this.getBasePath()' -- auth-manager.tsreturns nothing:that expression was never in the tree.
again", and its message records why: normalising the handed string made this
manager's own verifier reject every token its AS mints
(
ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "iss" claim value), fail-closed andpermanent, for a trailing-slash
basePath.createAuthInstancepassesconfiguredBasePath()— the configured value verbatim.Measured here, reading better-auth's own
(await auth.$context).baseURL— the value@better-auth/oauth-provider1.7.2 stamps as the access-tokeniss— off a realinstance, against
getAuthIssuer():basePathctx.baseURL(theiss)getAuthIssuer()/api/v1/authhttp://localhost:3000/api/v1/authapi/v1/authhttp://localhost:3000/api/v1/auth/api/v1/auth/http://localhost:3000/api/v1/auth/api/v1/auth/http://localhost:3000/api/v1/auth//api/v1/auth///http://localhost:3000/api/v1/auth////api/v9/identity/http://localhost:3000/api/v9/identity/⇒ there is no divergence to align. "Align the broadcast to the value the AS is
using right now" evaluates to change nothing, and canonicalising
getAuthIssuer()would create the fail-closed break #16380 measured and reverted. A new case pins
that equality against the real instance, so a later "canonicalisation" turns red
instead of shipping.
The single normaliser, and the sites that read it
One read of
this.config.basePathremains in the file, and one chain above it:rootedBasePath()is private and is the only place a leading slash is added;getBasePath()is the only place a trailing one is stripped (rootedBasePath()at :5628). A fourth normalisercannot be added without deleting a link of that chain. The two surviving values still
differ, deliberately: an issuer must mirror what better-auth stamps, trailing
slash and all; a mount path and a resource URL must be canonical.
What moves, and what provably does not
basePathgetMcpResourceUrl()before''·/api/v1/authhttp://localhost:3000/api/v1/mcp/api/v1/auth/http://localhost:3000/api/v1/mcp/api/v9/identityhttp://localhost:3000/api/v9/identity/mcpapi/v1/authhttp://localhost:3000api/v1/mcp(not a URL)http://localhost:3000/api/v1/mcpapi/v1/auth/http://localhost:3000api/v1/mcp(not a URL)http://localhost:3000/api/v1/mcp/api/v1/auth///http://localhost:3000/api/v1/auth////mcphttp://localhost:3000/api/v1/mcp/http://localhost:3000//mcphttp://localhost:3000/mcpgetBasePath()andgetAuthIssuer()are byte-identical to before on every row.Verification
than a literal: the values are constructed with
new URL(...)and allowed to throw.⛔ A hand-written literal would have re-encoded the bug.
basePathanswers byte-for-byte its currentvalues on all three getters — the existing canonical-only suite
(
auth-manager.mcp-oauth.test.ts) is untouched and green.trap ... EXIT INT TERM:reverting
getMcpResourceUrl()to the pre-fix derivation moved the on-disk blobd791d62b→04b4d9bf(anchor greps 1→0 and 0→1), turned the 5 new cases RED,and left the canonical-input control GREEN. Restored:
git diff HEADempty andthe blob back to
d791d62b.@objectstack/plugin-auth106 files / 2222 tests green;typecheckgreen(after building its own
dist— the first run'sTS2307was a missing prerequisite,not a finding);
@objectstack/verifyauth-base-path-contract.test.tsgreen;@objectstack/runtimehttp-dispatcher.mcp.test.tsgreen.0 UNRUN, on a tree fetched and merged to
origin/mainfe2b7554a.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3(PREREQUISITE NOT MET, not a pass); after a full workspace build both returned a
real 0.
check:route-enveloperun explicitly: green (my diff writes noc.json/res.json).pnpm lintgreen.Acceptance notes
6e0e4e87fcarries aRefs #16399trailer in its body. It was writtenand pushed before the PM's addendum forbidding card-relation trailers in commit
messages arrived. Per that addendum I have not amended, rebased or force-pushed,
and have opened no replacement PR.
check:partof-closing-keywordRULE 2 will red onit; it is advisory and outside the required-context set. The merge commit and this
body are clean.
basePathnon-canonically"was deliberately not used to narrow anything. Noted, not filed.
basePath: '/'yielding a//mcpresource path that no mount serves was inside thesame derivation and is repaired with it. Noted, not filed.
/api/v1/authis declared as two independent literals — AuthPlugin's constructor default and AuthManager's fallback — and a divergence between them is silent #16384 is untouched — a different pair of/api/v1/authliterals.What stays open on #16399
The card's defect 2. Not because it is unrepaired, but because it was not real:
this PR delivers the measurement that falsifies it plus the pin that keeps it
falsified. #16399 is deliberately not closed by this merge so the PM can read that
finding and decide whether the card is discharged or whether the card body and the
triage comment want a correction. The consolidation and defect 1 are complete.
Generated by Claude Code